每年,我都會固定幾個月建立相似的 Google 行事曆事件。雖然可以手動新增並設定重複,但面對來自多來源、條件不一的事件類型時,這件事變得越來越耗時。
所以,我寫了一段 Google Apps Script,讓這些年度任務可以自動建立並設定為重複,不再每次都得複製貼上。
這個自動化工具具備以下幾個能力:
透過一份簡單的表格,我可以清楚管理未來的提醒,也能隨時編輯內容來同步到行事曆。
我常常需要記得「提早查詢端午划龍舟日期」、「預約阿里山日出音樂會」這類每年固定要做的事情。它們的問題是:
所以,我偏好建立一個「4/1~5/30 的每週一到五早上 08:00–09:00」的提醒段落。這樣我每天打開行事曆就能看到這件事、卻不會太佔版面。
一開始我想要在每月的 1~5 號收到提醒,但 Google Calendar 並不支援「每月前五天」這樣的重複事件排程(特別是限定平日)。
後來我改變作法,使用 Google Apps Script 修改試算表的開始日與結束日為當月的1號到5號,再創建行事曆。
這樣每月會自動建立一段 5 天的事件區間,不需每月重新設定或個別新增,維護成本低、實用性高。
可以直接使用的 Google Apps Script 範例放在文章最後。
請依照下列欄位順序設定試算表內容,每列代表一個事件:
頻率 | 事件名稱 | 內容說明 | 開始日期 | 結束日期 | 開始時間 | 結束時間 | 重複類型 |
---|---|---|---|---|---|---|---|
年 | 檢查年度開銷 | 房屋、所得稅、保費 | 4月1日 | 5月31日 | 15:30 | 16:30 | 平日 |
年 | 報名龍舟 | 關切活動舉辦:KSD高雄市政府運動發展局 | 3月20日 | 4月10日 | 15:30 | 16:30 | 平日 |
年 | 🗿預約阿里山日出音樂會 | 12月1日 | 12月15日 | 15:30 | 16:30 | 平日 | |
年 | 查臺東熱氣球 | 5月1日 | 5月15日 | 15:30 | 16:30 | 平日 | |
年 | 熱氣球預約停車 | https://www.luyepark.com.tw/ | 6月15日 | 6月30日 | 15:30 | 16:30 | 平日 |
月 | 管理費 | 固定每月 1~5 號 | 6月1日 | 6月5日 | 15:30 | 16:30 | 每日 |
📌 備註:
- 「頻率」僅支援
年
或月
,其他將略過。- 「重複類型」建議使用
平日
或每日
,分別對應到 weekday-only 或 daily rule。- 日期欄位請確保為日期格式(非文字),時間欄位也建議使用時間格式。
為了讓這段腳本能自動運作,請使用 時間驅動觸發器(Time-driven trigger):
createMonthlyEvents
函式triggerAnnualEvents
函式這樣您每個月一打開行事曆,就會看到自動建立好的新事件。
「請幫我寫一段 Apps Script,自動讀取 Google Sheet 中頻率為『年』或『月』的事件,並在 Google Calendar 建立每年/每月的平日提醒。」
也可以這樣加上條件:
「每月事件需自動更新日期區間為當月1~5號,再逐天建立平日事件,不使用重複事件功能。」
透過這個工具,我現在只要維護一張表格,所有提醒就能自動更新到 Google Calendar。
更棒的是,我可以清楚掌握每個事件的重複邏輯與建立狀態,不再怕重複、不再怕漏掉。
這是一種「把記憶交給程式」的感覺。
// === 每月任務入口 ===
function createMonthlyEvents() {
updateMonthlyExpensesDates();
processEventsByFrequency("月");
}
function updateMonthlyExpensesDates() {
const sheetId = "YOUR_SHEET_ID";
const sheet = SpreadsheetApp.openById(sheetId).getSheetByName("事件行事曆");
const data = sheet.getDataRange().getValues();
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth();
const startDate = new Date(year, month, 1);
const endDate = new Date(year, month, 5);
for (let i = 1; i < data.length; i++) {
const row = data[i];
const frequency = row[0];
if (frequency === "月") {
sheet.getRange(i + 1, 4).setValue(startDate);
sheet.getRange(i + 1, 5).setValue(endDate);
}
}
}
// === 年度任務入口 ===
function triggerAnnualEvents() {
const today = new Date();
if (today.getMonth() === 0 && today.getDate() === 1) {
createAnnualEvents();
} else {
Logger.log("❌ 今天不是 1月1日,略過年度事件建立");
}
}
function createAnnualEvents() {
processEventsByFrequency("年");
}
function processEventsByFrequency(targetFrequency) {
const sheetId = "YOUR_sheetId";
const sourceSheetName = "事件行事曆";
const logSheetName = "執行紀錄";
const calendar = CalendarApp.getDefaultCalendar();
const data = getSheetData(sheetId, sourceSheetName);
const results = [];
const LINE_CHANNEL_ACCESS_TOKEN = 'YOUR_LINE_CHANNEL_ACCESS_TOKEN';
const USER_ID = 'YOUR_USER_ID';
for (let i = 1; i < data.length; i++) {
const [frequency, title, description, startText, endText, startTimeText, endTimeText, repeatType] = data[i];
const rowIndex = i + 1;
if (frequency !== targetFrequency) {
const msg = `🔸 跳過第 ${rowIndex} 列:「${title}」→ 頻率不符(${frequency})`;
results.push(msg);
Logger.log(msg);
continue;
}
try {
const startDate = parseDateTime(startText, startTimeText);
const endDate = parseDateTime(startText, endTimeText);
const untilDate = parseDateTime(endText, "23:59");
if (checkIfEventExists(calendar, title, startDate, endDate)) {
const msg = `⏭️ 已存在 第 ${rowIndex} 列:「${title}」\n時間:${formatDateTime(startDate)} ~ ${formatTime(endDate)}`;
results.push(msg);
Logger.log(msg);
continue;
}
let recurrence;
if (frequency === "年") {
recurrence = buildWeekdayRecurrence(untilDate);
} else if (frequency === "月") {
const diffDays = Math.floor((untilDate - startDate) / (1000 * 60 * 60 * 24)) + 1;
recurrence = CalendarApp.newRecurrence()
.addDailyRule()
.times(diffDays);
} else {
const msg = `⚠️ 不支援的頻率類型:「${frequency}」於第 ${rowIndex} 列`;
results.push(msg);
Logger.log(msg);
continue;
}
calendar.createEventSeries(title, startDate, endDate, recurrence, {
description: description
});
const msg = `✅ 已建立 第 ${rowIndex} 列:「${title}」\n時間:${formatDateTime(startDate)} ~ ${formatTime(endDate)}\n重複:${frequency}`;
results.push(msg);
Logger.log(msg);
Utilities.sleep(200);
} catch (e) {
const msg = `❌ 錯誤 第 ${rowIndex} 列:「${title}」→ ${e.message}`;
results.push(msg);
Logger.log(msg);
}
}
const summary = `📅 ${targetFrequency} 行事曆建立結果(共 ${results.length} 筆)\n\n${results.join("\n\n")}`;
sendLineMessage(summary, LINE_CHANNEL_ACCESS_TOKEN, USER_ID);
writeExecutionLog(sheetId, logSheetName, results);
}
function getSheetData(sheetId, sheetName) {
const sheet = SpreadsheetApp.openById(sheetId).getSheetByName(sheetName);
if (!sheet) throw new Error("🚫 找不到工作表:「" + sheetName + "」");
return sheet.getDataRange().getValues();
}
function parseDateTime(baseDate, timeInput) {
if (!(baseDate instanceof Date)) throw new Error("🚫 日期欄位應為 Date 物件:" + baseDate);
let hour = 0, minute = 0;
if (typeof timeInput === 'string') {
[hour, minute] = timeInput.split(":").map(n => parseInt(n, 10));
} else if (timeInput instanceof Date) {
hour = timeInput.getHours();
minute = timeInput.getMinutes();
} else {
throw new Error("❌ 時間格式錯誤:" + timeInput);
}
const newDate = new Date(baseDate);
newDate.setHours(hour, minute, 0, 0);
return newDate;
}
function checkIfEventExists(calendar, title, startTime, endTime) {
const events = calendar.getEvents(startTime, endTime);
return events.some(e =>
e.isRecurringEvent() &&
e.getTitle() === title &&
e.getStartTime().getHours() === startTime.getHours()
);
}
function buildWeekdayRecurrence(untilDate) {
return CalendarApp.newRecurrence()
.addWeeklyRule()
.onlyOnWeekdays([
CalendarApp.Weekday.MONDAY,
CalendarApp.Weekday.TUESDAY,
CalendarApp.Weekday.WEDNESDAY,
CalendarApp.Weekday.THURSDAY,
CalendarApp.Weekday.FRIDAY
])
.until(untilDate);
}
function writeExecutionLog(sheetId, logSheetName, results) {
const sheet = SpreadsheetApp.openById(sheetId).getSheetByName(logSheetName);
if (!sheet) throw new Error("🚫 找不到『" + logSheetName + "』工作表");
const timestamp = Utilities.formatDate(new Date(), Session.getScriptTimeZone(), "yyyy-MM-dd HH:mm:ss");
const rows = results.map(msg => [timestamp, msg]);
sheet.getRange(sheet.getLastRow() + 1, 1, rows.length, rows[0].length).setValues(rows);
}
function sendLineMessage(message, token, userId) {
const url = "https://api.line.me/v2/bot/message/push";
const payload = {
to: userId,
messages: [{ type: "text", text: message }]
};
const options = {
method: "post",
contentType: "application/json",
payload: JSON.stringify(payload),
headers: { Authorization: "Bearer " + token }
};
UrlFetchApp.fetch(url, options);
}